index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. // @ts-nocheck
  2. import { useQuery } from '@tanstack/react-query'
  3. import { IS_PLATFORM, useFeatureFlags } from 'common'
  4. import { Database } from 'common/marketplace.types'
  5. import { Search } from 'lucide-react'
  6. import { useRouter } from 'next/router'
  7. import { parseAsString, useQueryState } from 'nuqs'
  8. import { useMemo, type ReactNode } from 'react'
  9. import { ShimmeringLoader } from 'ui-patterns'
  10. import { Input } from 'ui-patterns/DataInputs/Input'
  11. import { PageContainer } from 'ui-patterns/PageContainer'
  12. import {
  13. PageHeader,
  14. PageHeaderAside,
  15. PageHeaderDescription,
  16. PageHeaderMeta,
  17. PageHeaderSummary,
  18. PageHeaderTitle,
  19. } from 'ui-patterns/PageHeader'
  20. import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection'
  21. import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
  22. import {
  23. IntegrationCard,
  24. IntegrationLoadingCard,
  25. } from '@/components/interfaces/Integrations/Landing/IntegrationCard'
  26. import { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
  27. import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
  28. import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
  29. import { MarketplaceIndex } from '@/components/interfaces/Integrations/Marketplace/MarketplaceIndex'
  30. import { DefaultLayout } from '@/components/layouts/DefaultLayout'
  31. import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
  32. import { AlertError } from '@/components/ui/AlertError'
  33. import { DocsButton } from '@/components/ui/DocsButton'
  34. import { NoSearchResults } from '@/components/ui/NoSearchResults'
  35. import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query'
  36. import { BASE_PATH, DOCS_URL } from '@/lib/constants'
  37. import type { NextPageWithLayout } from '@/types'
  38. const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_sync_engine']
  39. // Featured integration images
  40. const FEATURED_INTEGRATION_IMAGES: Record<string, string> = {
  41. cron: `${BASE_PATH}/img/integrations/covers/cron-cover.webp`,
  42. queues: `${BASE_PATH}/img/integrations/covers/queues-cover.png`,
  43. stripe_wrapper: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
  44. stripe_sync_engine: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
  45. }
  46. function getIntegrationImage(integration: IntegrationDefinition) {
  47. let featured_image = FEATURED_INTEGRATION_IMAGES[integration.id]
  48. if (featured_image) {
  49. return featured_image
  50. }
  51. if (integration.files?.length) {
  52. const heroImage = integration?.files?.[0]
  53. return heroImage
  54. }
  55. }
  56. type PageContent = {
  57. title: string
  58. subtitle: string
  59. secondaryActions?: ReactNode
  60. }
  61. const DEFAULT_PAGE_CONTENT: PageContent = {
  62. title: 'Extend your database',
  63. subtitle:
  64. 'Extensions and wrappers that add functionality to your database and connect to external services.',
  65. }
  66. const CATEGORY_PAGE_CONTENT = {
  67. wrapper: {
  68. title: 'Wrappers',
  69. subtitle:
  70. 'Connect to external data sources and services by querying APIs, databases, and files as if they were Postgres tables.',
  71. secondaryActions: (
  72. <DocsButton href={`${DOCS_URL}/guides/database/extensions/wrappers/overview`} />
  73. ),
  74. },
  75. postgres_extension: {
  76. title: 'Postgres Modules',
  77. subtitle: 'Extend your database with powerful Postgres extensions.',
  78. },
  79. } satisfies Record<string, PageContent>
  80. // Converts a category string to title
  81. // Example: some_catory -> Some Category
  82. function formatCategoryTitle(category: string) {
  83. return category
  84. .split(/[-_]/)
  85. .filter(Boolean)
  86. .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
  87. .join(' ')
  88. }
  89. const PageHeaderContentSkeleton = () => (
  90. <div className="flex flex-col gap-y-2">
  91. <ShimmeringLoader className="h-8 w-48" />
  92. <ShimmeringLoader className="h-4 w-full max-w-xl" />
  93. </div>
  94. )
  95. // Returns selected category to filter by
  96. function useFilterCategory() {
  97. const router = useRouter()
  98. const [selectedCategory] = useQueryState(
  99. 'category',
  100. parseAsString.withDefault('all').withOptions({ clearOnDefault: true })
  101. )
  102. const categoryFromUrl = useMemo(() => {
  103. const queryString = router.asPath.split('?')[1]
  104. if (!queryString) return null
  105. return new URLSearchParams(queryString).get('category')
  106. }, [router.asPath])
  107. const resolvedSelectedCategory = router.isReady
  108. ? (categoryFromUrl ?? selectedCategory)
  109. : undefined
  110. const filterCategory = resolvedSelectedCategory ?? 'all'
  111. return filterCategory
  112. }
  113. // Dynamic page content based on selected category
  114. function usePageContent(
  115. integrationFilterCategory: string,
  116. categories: Database['public']['Views']['categories']['Row'][]
  117. ) {
  118. const pageContent = useMemo<PageContent>(() => {
  119. if (integrationFilterCategory === 'all') {
  120. return DEFAULT_PAGE_CONTENT
  121. }
  122. if (integrationFilterCategory in CATEGORY_PAGE_CONTENT) {
  123. return CATEGORY_PAGE_CONTENT[integrationFilterCategory as keyof typeof CATEGORY_PAGE_CONTENT]
  124. }
  125. const selectedMarketplaceCategory = categories.find(
  126. (category) => category.slug === integrationFilterCategory
  127. )
  128. return {
  129. title: selectedMarketplaceCategory?.name ?? formatCategoryTitle(integrationFilterCategory),
  130. subtitle: selectedMarketplaceCategory?.description ?? DEFAULT_PAGE_CONTENT.subtitle,
  131. }
  132. }, [categories, integrationFilterCategory])
  133. return pageContent
  134. }
  135. // Filters all available integrations first by category,
  136. // then by the search term and sorts them first by
  137. // installation status and then alphabetically
  138. function useFilteredAndSortedIntegrations(
  139. availableIntegrations: IntegrationDefinition[],
  140. selectedCategory: string,
  141. search: string,
  142. installedIds: string[]
  143. ) {
  144. const filteredAndSortedIntegrations = useMemo(() => {
  145. let filtered = availableIntegrations ?? []
  146. if (selectedCategory !== 'all') {
  147. filtered = filtered.filter(
  148. (i) => i.type === selectedCategory || i.categories?.includes(selectedCategory)
  149. )
  150. }
  151. if (search.length > 0) {
  152. filtered = filtered.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
  153. }
  154. // Sort by installation status, then alphabetically
  155. return filtered.sort((a, b) => {
  156. const aIsInstalled = installedIds.includes(a.id)
  157. const bIsInstalled = installedIds.includes(b.id)
  158. if (aIsInstalled && !bIsInstalled) return -1
  159. if (!aIsInstalled && bIsInstalled) return 1
  160. return a.name.localeCompare(b.name)
  161. })
  162. }, [availableIntegrations, selectedCategory, search, installedIds])
  163. return filteredAndSortedIntegrations
  164. }
  165. // Returns featured integrations
  166. function useFeaturedIntegratios(
  167. filteredAndSortedIntegrations: IntegrationDefinition[],
  168. selectedCategory: string,
  169. search: string
  170. ) {
  171. const groupedIntegrations = useMemo(() => {
  172. if (selectedCategory !== 'all' || search.length > 0) {
  173. return null
  174. }
  175. const featured = filteredAndSortedIntegrations.filter(
  176. (integration) => FEATURED_INTEGRATIONS.includes(integration.id) || integration.featured
  177. )
  178. return featured
  179. }, [filteredAndSortedIntegrations, selectedCategory, search])
  180. return groupedIntegrations
  181. }
  182. const IntegrationsPage: NextPageWithLayout = () => {
  183. const isMarketplaceEnabled = useIsMarketplaceEnabled()
  184. if (isMarketplaceEnabled) return <MarketplaceIndex />
  185. return <LegacyIntegrationsPage />
  186. }
  187. const LegacyIntegrationsPage = () => {
  188. const { hasLoaded: flagsLoaded } = useFeatureFlags()
  189. const isMarketplaceEnabled = useIsMarketplaceEnabled()
  190. const [search, setSearch] = useQueryState(
  191. 'search',
  192. parseAsString.withDefault('').withOptions({ clearOnDefault: true })
  193. )
  194. const {
  195. data: availableIntegrations,
  196. error,
  197. isPending: isLoadingAvailableIntegrations,
  198. isError,
  199. isSuccess: isSuccessAvailableIntegrations,
  200. } = useAvailableIntegrations()
  201. const {
  202. installedIntegrations,
  203. isLoading: isLoadingInstalledIntegrations,
  204. isSuccess: isSuccessInstalledIntegrations,
  205. } = useInstalledIntegrations()
  206. const installedIds = installedIntegrations.map((i) => i.id)
  207. const isLoading = isLoadingAvailableIntegrations || isLoadingInstalledIntegrations
  208. const isSuccess = isSuccessAvailableIntegrations && isSuccessInstalledIntegrations
  209. const selectedCategory = useFilterCategory()
  210. const { data: categories = [], isPending: isPendingCategories } = useQuery(
  211. useMarketplaceCategoriesQuery({ enabled: isMarketplaceEnabled })
  212. )
  213. const isLoadingSelectedCategory =
  214. selectedCategory !== 'all' &&
  215. !(selectedCategory in CATEGORY_PAGE_CONTENT) &&
  216. (IS_PLATFORM ? !flagsLoaded || (isMarketplaceEnabled && isPendingCategories) : false)
  217. const pageContent = usePageContent(selectedCategory, categories)
  218. const filteredAndSortedIntegrations = useFilteredAndSortedIntegrations(
  219. availableIntegrations,
  220. selectedCategory,
  221. search,
  222. installedIds
  223. )
  224. const featuredIntegrations = useFeaturedIntegratios(
  225. filteredAndSortedIntegrations,
  226. selectedCategory,
  227. search
  228. )
  229. return (
  230. <>
  231. <PageHeader size="large">
  232. <PageHeaderMeta>
  233. <PageHeaderSummary>
  234. {isLoadingSelectedCategory ? (
  235. <PageHeaderContentSkeleton />
  236. ) : (
  237. <>
  238. <PageHeaderTitle>{pageContent.title}</PageHeaderTitle>
  239. <PageHeaderDescription>{pageContent.subtitle}</PageHeaderDescription>
  240. </>
  241. )}
  242. </PageHeaderSummary>
  243. {pageContent.secondaryActions && (
  244. <PageHeaderAside>{pageContent.secondaryActions}</PageHeaderAside>
  245. )}
  246. </PageHeaderMeta>
  247. </PageHeader>
  248. <PageContainer size="large">
  249. <PageSection>
  250. <PageSectionMeta>
  251. <Input
  252. value={search}
  253. size="tiny"
  254. onChange={(e) => setSearch(e.target.value)}
  255. placeholder="Search integrations..."
  256. icon={<Search size={14} />}
  257. className="w-52"
  258. />
  259. </PageSectionMeta>
  260. <PageSectionContent>
  261. {isLoading && (
  262. <div
  263. className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3"
  264. style={{ gridAutoRows: 'minmax(110px, auto)' }}
  265. >
  266. {Array.from({ length: 8 }).map((_, idx) => (
  267. <IntegrationLoadingCard key={`integration-loading-${idx}`} />
  268. ))}
  269. </div>
  270. )}
  271. {/* Error State */}
  272. {isError && (
  273. <AlertError subject="Failed to retrieve available integrations" error={error} />
  274. )}
  275. {/* Success State */}
  276. {isSuccess && (
  277. <>
  278. {/* No Search Results */}
  279. {search.length > 0 && filteredAndSortedIntegrations.length === 0 && (
  280. <NoSearchResults searchString={search} onResetFilter={() => setSearch('')} />
  281. )}
  282. {/* Featured Integrations */}
  283. {featuredIntegrations && featuredIntegrations.length > 0 && (
  284. <div
  285. className="grid grid-cols-2 @4xl:grid-cols-3 gap-4 mb-4 items-stretch pb-6 border-b"
  286. style={{ gridAutoRows: 'minmax(110px, auto)' }}
  287. >
  288. {featuredIntegrations.map((integration) => (
  289. <IntegrationCard
  290. key={integration.id}
  291. {...integration}
  292. isInstalled={installedIds.includes(integration.id)}
  293. featured={true}
  294. image={getIntegrationImage(integration)}
  295. />
  296. ))}
  297. </div>
  298. )}
  299. {/* All Filtered and Sorted Integrations */}
  300. {filteredAndSortedIntegrations.length > 0 && (
  301. <div className="grid @xl:grid-cols-3 @6xl:grid-cols-4 gap-4">
  302. {filteredAndSortedIntegrations.map((integration) => (
  303. <IntegrationCard
  304. key={integration.id}
  305. {...integration}
  306. isInstalled={installedIds.includes(integration.id)}
  307. featured={false}
  308. image={FEATURED_INTEGRATION_IMAGES[integration.id]}
  309. />
  310. ))}
  311. </div>
  312. )}
  313. </>
  314. )}
  315. </PageSectionContent>
  316. </PageSection>
  317. </PageContainer>
  318. </>
  319. )
  320. }
  321. IntegrationsPage.getLayout = (page) => (
  322. <DefaultLayout>
  323. <ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
  324. </DefaultLayout>
  325. )
  326. export default IntegrationsPage